Skip to content

dash_charts.time_vis_chart⚓︎

R daattali/TimeVis-like charts for displaying chronological data.

See daattali/TimeVis: https://github.com/daattali/timevis

NOTE: Consider automated (non-overlapping) text/event placement⚓︎

  • MATLAB Adjust Text: https://github.com/Phlya/adjustText
  • D3 Labeler: https://github.com/tinker10/D3-Labeler
  • Plotly has implementation for contour? https://github.com/plotly/plotly.js/issues/4674#issuecomment-603571483
View Source
"""R daattali/TimeVis-like charts for displaying chronological data.

See daattali/TimeVis: https://github.com/daattali/timevis

# NOTE: Consider automated (non-overlapping) text/event placement

- MATLAB Adjust Text: https://github.com/Phlya/adjustText
- D3 Labeler: https://github.com/tinker10/D3-Labeler
- Plotly has implementation for contour? https://github.com/plotly/plotly.js/issues/4674#issuecomment-603571483

"""

import numpy as np
import plotly.graph_objects as go

from .utils_data import DASHED_TIME_FORMAT_YEAR, GDP_TIME_FORMAT, format_unix, get_unix
from .utils_fig import CustomChart


class TimeVisChart(CustomChart):  # noqa: H601
    """Time Vis Chart: resource use timeline."""

    date_format = DASHED_TIME_FORMAT_YEAR
    """Date format for bar chart. Default is `DASHED_TIME_FORMAT_YEAR`."""

    fillcolor = '#D5DDF6'
    """Default fillcolor for time vis events."""

    hover_label_settings = {'bgcolor': 'white', 'font_size': 12, 'namelength': 0}
    """Plotly hover label settings."""

    rh = 1
    """Height of each rectangular time vis."""

    y_space = -1.5 * rh
    """Vertical spacing between rectangles."""

    categories = None
    """List of string category names set in self.create_traces()."""

    _shapes = []
    """List of shapes for plotly layout."""

    def create_traces(self, df_raw):   # noqa: CCR001
        """Return traces for plotly chart.

        Args:
            df_raw: pandas dataframe with columns: `(category, label, start, end)`

        Returns:
            list: Dash chart traces

        """
        # Get all unique category names and create lookup for y positions
        self.categories = sorted(cat for cat in set(df_raw['category'].tolist()) if cat)
        y_pos_lookup = {cat: self.y_space * idx for idx, cat in enumerate(self.categories)}
        # Create the Time Vis traces
        traces = []
        self._shapes = []
        self._annotations = []
        for vis in df_raw.itertuples():
            if vis.category in y_pos_lookup:
                y_pos = y_pos_lookup[vis.category]
                if vis.end:
                    traces.append(self._create_time_vis_shape(vis, y_pos))
                    if vis.label:
                        traces.append(self._create_annotation(vis, y_pos))
                else:
                    traces.append(self._create_event(vis, y_pos))
            else:
                y_pos = 0
                traces.append(self._create_non_cat_shape(vis, y_pos))
        return traces

    def _create_hover_text(self, vis):
        """Return hover text for given trace.

        Args:
            vis: row tuple from df_raw with: `(category, label, start, end)`

        Returns:
            string: HTML-formatted hover text

        """
        new_format = f'%a, {GDP_TIME_FORMAT}'
        start_date = format_unix(get_unix(vis.start, self.date_format), new_format)
        if vis.end:
            end_date = format_unix(get_unix(vis.end, self.date_format), new_format)
            date_range = f'<b>Start</b>: {start_date}<br><b>End</b>: {end_date}'
        else:
            date_range = f'<b>Event</b>: {start_date}'
        return f'<b>{vis.category}</b><br>{vis.label}<br>{date_range}'

    def _create_non_cat_shape(self, vis, y_pos):
        """Create non-category time visualization (vertical across all categories).

        Note: background shape is set below a transparent trace so that hover works

        Args:
            vis: row tuple from df_raw with: `(category, label, start, end)`
            y_pos: top y-coordinate of vis

        Returns:
            trace: single Dash chart Scatter trace

        """
        bot_y = self.y_space * len(self.categories)
        self._shapes.append(
            go.layout.Shape(
                fillcolor=self.fillcolor,
                layer='below',
                line={'width': 0},
                opacity=0.4,
                type='rect',
                x0=vis.start,
                x1=vis.end,
                xref='x',
                y0=bot_y,
                y1=y_pos,
                yref='y',
            ),
        )
        return go.Scatter(
            fill='toself',
            opacity=0,
            hoverlabel=self.hover_label_settings,
            line={'width': 0},
            mode='lines',
            text=self._create_hover_text(vis),
            x=[vis.start, vis.end, vis.end, vis.start, vis.start],
            y=[y_pos, y_pos, bot_y, bot_y, y_pos],
        )

    def _create_time_vis_shape(self, vis, y_pos):
        """Create filled rectangle for time visualization.

        Args:
            vis: row tuple from df_raw with: `(category, label, start, end)`
            y_pos: top y-coordinate of vis

        Returns:
            trace: single Dash chart Scatter trace

        """
        return go.Scatter(
            fill='toself',
            fillcolor=self.fillcolor,
            hoverlabel=self.hover_label_settings,
            line={'width': 0},
            mode='lines',
            text=self._create_hover_text(vis),
            x=[vis.start, vis.end, vis.end, vis.start, vis.start],
            y=[y_pos, y_pos, y_pos - self.rh, y_pos - self.rh, y_pos],
        )

    def _create_annotation(self, vis, y_pos):
        """Add vis label to chart as text overlay.

        Args:
            vis: row tuple from df_raw with: `(category, label, start, end)`
            y_pos: top y-coordinate of vis

        Returns:
            trace: single Dash chart Scatter trace

        """
        return go.Scatter(
            hoverlabel=self.hover_label_settings,
            hovertemplate=self._create_hover_text(vis) + '<extra></extra>',
            hovertext=self._create_hover_text(vis),
            mode='text',
            text=vis.label,
            textposition='middle right',
            x=[vis.start],
            y=[y_pos - self.rh / 2],
        )

    def _create_event(self, vis, y_pos):
        """Create singular event with vertical line, marker, and text.

        If label is longer than 10 characters, then the annotation is shown offset with an arrow.

        Args:
            vis: row tuple from df_raw with: `(category, label, start, end)`
            y_pos: top y-coordinate of vis

        Returns:
            trace: single Dash chart Scatter trace

        """
        if len(vis.label) > 10:
            self._annotations.append({
                'align': 'right',
                'arrowcolor': self.fillcolor,
                'showarrow': True,
                'arrowhead': 2,
                'text': vis.label,
                'x': vis.start,
                'xanchor': 'right',
                'y': y_pos - self.rh / 2,
                'yanchor': 'middle',
            })
        self._shapes.append(
            go.layout.Shape(
                layer='below',
                line={
                    'color': self.fillcolor,
                    'dash': 'longdashdot',
                    'width': 2,
                },
                type='line',
                x0=vis.start,
                x1=vis.start,
                xref='x',
                y0=self.y_space * len(self.categories),
                y1=y_pos - self.rh / 2,
                yref='y',
            ),
        )
        return go.Scatter(
            hoverlabel=self.hover_label_settings,
            hovertemplate=self._create_hover_text(vis) + '<extra></extra>',
            hovertext=self._create_hover_text(vis),
            marker={'color': self.fillcolor},
            mode='markers+text',
            text='' if len(vis.label) > 10 else vis.label,
            textposition='top center',
            x=[vis.start],
            y=[y_pos - self.rh / 2],
        )

    def create_layout(self):
        """Extend the standard layout.

        Returns:
            dict: layout for Dash figure

        """
        layout = super().create_layout()
        # Set YAxis tick marks for category names (https://plotly.com/python/tick-formatting)
        layout['yaxis']['tickmode'] = 'array'
        layout['yaxis']['tickvals'] = np.subtract(
            np.multiply(
                np.array(range(len(self.categories))),
                self.y_space,
            ),
            self.rh / 2,
        )
        layout['yaxis']['ticktext'] = [*self.categories]
        layout['yaxis']['zeroline'] = False
        # Hide legend
        layout['legend'] = {}
        layout['showlegend'] = False
        # Add shapes and append new annotations
        layout['shapes'] = self._shapes
        layout['annotations'] += self._annotations
        return layout

Variables⚓︎

DASHED_TIME_FORMAT_YEAR
GDP_TIME_FORMAT

Classes⚓︎

TimeVisChart⚓︎

class TimeVisChart(
    *,
    title,
    xlabel,
    ylabel,
    layout_overrides=()
)
View Source
class TimeVisChart(CustomChart):  # noqa: H601
    """Time Vis Chart: resource use timeline."""

    date_format = DASHED_TIME_FORMAT_YEAR
    """Date format for bar chart. Default is `DASHED_TIME_FORMAT_YEAR`."""

    fillcolor = '#D5DDF6'
    """Default fillcolor for time vis events."""

    hover_label_settings = {'bgcolor': 'white', 'font_size': 12, 'namelength': 0}
    """Plotly hover label settings."""

    rh = 1
    """Height of each rectangular time vis."""

    y_space = -1.5 * rh
    """Vertical spacing between rectangles."""

    categories = None
    """List of string category names set in self.create_traces()."""

    _shapes = []
    """List of shapes for plotly layout."""

    def create_traces(self, df_raw):   # noqa: CCR001
        """Return traces for plotly chart.

        Args:
            df_raw: pandas dataframe with columns: `(category, label, start, end)`

        Returns:
            list: Dash chart traces

        """
        # Get all unique category names and create lookup for y positions
        self.categories = sorted(cat for cat in set(df_raw['category'].tolist()) if cat)
        y_pos_lookup = {cat: self.y_space * idx for idx, cat in enumerate(self.categories)}
        # Create the Time Vis traces
        traces = []
        self._shapes = []
        self._annotations = []
        for vis in df_raw.itertuples():
            if vis.category in y_pos_lookup:
                y_pos = y_pos_lookup[vis.category]
                if vis.end:
                    traces.append(self._create_time_vis_shape(vis, y_pos))
                    if vis.label:
                        traces.append(self._create_annotation(vis, y_pos))
                else:
                    traces.append(self._create_event(vis, y_pos))
            else:
                y_pos = 0
                traces.append(self._create_non_cat_shape(vis, y_pos))
        return traces

    def _create_hover_text(self, vis):
        """Return hover text for given trace.

        Args:
            vis: row tuple from df_raw with: `(category, label, start, end)`

        Returns:
            string: HTML-formatted hover text

        """
        new_format = f'%a, {GDP_TIME_FORMAT}'
        start_date = format_unix(get_unix(vis.start, self.date_format), new_format)
        if vis.end:
            end_date = format_unix(get_unix(vis.end, self.date_format), new_format)
            date_range = f'<b>Start</b>: {start_date}<br><b>End</b>: {end_date}'
        else:
            date_range = f'<b>Event</b>: {start_date}'
        return f'<b>{vis.category}</b><br>{vis.label}<br>{date_range}'

    def _create_non_cat_shape(self, vis, y_pos):
        """Create non-category time visualization (vertical across all categories).

        Note: background shape is set below a transparent trace so that hover works

        Args:
            vis: row tuple from df_raw with: `(category, label, start, end)`
            y_pos: top y-coordinate of vis

        Returns:
            trace: single Dash chart Scatter trace

        """
        bot_y = self.y_space * len(self.categories)
        self._shapes.append(
            go.layout.Shape(
                fillcolor=self.fillcolor,
                layer='below',
                line={'width': 0},
                opacity=0.4,
                type='rect',
                x0=vis.start,
                x1=vis.end,
                xref='x',
                y0=bot_y,
                y1=y_pos,
                yref='y',
            ),
        )
        return go.Scatter(
            fill='toself',
            opacity=0,
            hoverlabel=self.hover_label_settings,
            line={'width': 0},
            mode='lines',
            text=self._create_hover_text(vis),
            x=[vis.start, vis.end, vis.end, vis.start, vis.start],
            y=[y_pos, y_pos, bot_y, bot_y, y_pos],
        )

    def _create_time_vis_shape(self, vis, y_pos):
        """Create filled rectangle for time visualization.

        Args:
            vis: row tuple from df_raw with: `(category, label, start, end)`
            y_pos: top y-coordinate of vis

        Returns:
            trace: single Dash chart Scatter trace

        """
        return go.Scatter(
            fill='toself',
            fillcolor=self.fillcolor,
            hoverlabel=self.hover_label_settings,
            line={'width': 0},
            mode='lines',
            text=self._create_hover_text(vis),
            x=[vis.start, vis.end, vis.end, vis.start, vis.start],
            y=[y_pos, y_pos, y_pos - self.rh, y_pos - self.rh, y_pos],
        )

    def _create_annotation(self, vis, y_pos):
        """Add vis label to chart as text overlay.

        Args:
            vis: row tuple from df_raw with: `(category, label, start, end)`
            y_pos: top y-coordinate of vis

        Returns:
            trace: single Dash chart Scatter trace

        """
        return go.Scatter(
            hoverlabel=self.hover_label_settings,
            hovertemplate=self._create_hover_text(vis) + '<extra></extra>',
            hovertext=self._create_hover_text(vis),
            mode='text',
            text=vis.label,
            textposition='middle right',
            x=[vis.start],
            y=[y_pos - self.rh / 2],
        )

    def _create_event(self, vis, y_pos):
        """Create singular event with vertical line, marker, and text.

        If label is longer than 10 characters, then the annotation is shown offset with an arrow.

        Args:
            vis: row tuple from df_raw with: `(category, label, start, end)`
            y_pos: top y-coordinate of vis

        Returns:
            trace: single Dash chart Scatter trace

        """
        if len(vis.label) > 10:
            self._annotations.append({
                'align': 'right',
                'arrowcolor': self.fillcolor,
                'showarrow': True,
                'arrowhead': 2,
                'text': vis.label,
                'x': vis.start,
                'xanchor': 'right',
                'y': y_pos - self.rh / 2,
                'yanchor': 'middle',
            })
        self._shapes.append(
            go.layout.Shape(
                layer='below',
                line={
                    'color': self.fillcolor,
                    'dash': 'longdashdot',
                    'width': 2,
                },
                type='line',
                x0=vis.start,
                x1=vis.start,
                xref='x',
                y0=self.y_space * len(self.categories),
                y1=y_pos - self.rh / 2,
                yref='y',
            ),
        )
        return go.Scatter(
            hoverlabel=self.hover_label_settings,
            hovertemplate=self._create_hover_text(vis) + '<extra></extra>',
            hovertext=self._create_hover_text(vis),
            marker={'color': self.fillcolor},
            mode='markers+text',
            text='' if len(vis.label) > 10 else vis.label,
            textposition='top center',
            x=[vis.start],
            y=[y_pos - self.rh / 2],
        )

    def create_layout(self):
        """Extend the standard layout.

        Returns:
            dict: layout for Dash figure

        """
        layout = super().create_layout()
        # Set YAxis tick marks for category names (https://plotly.com/python/tick-formatting)
        layout['yaxis']['tickmode'] = 'array'
        layout['yaxis']['tickvals'] = np.subtract(
            np.multiply(
                np.array(range(len(self.categories))),
                self.y_space,
            ),
            self.rh / 2,
        )
        layout['yaxis']['ticktext'] = [*self.categories]
        layout['yaxis']['zeroline'] = False
        # Hide legend
        layout['legend'] = {}
        layout['showlegend'] = False
        # Add shapes and append new annotations
        layout['shapes'] = self._shapes
        layout['annotations'] += self._annotations
        return layout

Ancestors (in MRO)⚓︎

  • dash_charts.utils_fig.CustomChart

Class variables⚓︎

annotations
categories

List of string category names set in self.create_traces().

date_format

Date format for bar chart. Default is DASHED_TIME_FORMAT_YEAR.

fillcolor

Default fillcolor for time vis events.

hover_label_settings

Plotly hover label settings.

rh

Height of each rectangular time vis.

y_space

Vertical spacing between rectangles.

Instance variables⚓︎

axis_range

Specify x/y axis range or leave as empty dictionary for autorange.

Methods⚓︎

apply_custom_layout⚓︎

def apply_custom_layout(
    self,
    layout
)

Extend and/or override layout with custom settings.

Parameters:

Name Description
layout base layout dictionary. Typically from self.create_layout()

Returns:

Type Description
dict layout for Dash figure
View Source
    def apply_custom_layout(self, layout):
        """Extend and/or override layout with custom settings.

        Args:
            layout: base layout dictionary. Typically from self.create_layout()

        Returns:
            dict: layout for Dash figure

        """
        for parent_key, sub_key, value in self.layout_overrides:
            if sub_key is not None:
                layout[parent_key][sub_key] = value
            else:
                layout[parent_key] = value

        return layout

create_figure⚓︎

def create_figure(
    self,
    df_raw,
    **kwargs_data
)

Create the figure dictionary.

Parameters:

Name Description
df_raw data to pass to formatter method
kwargs_data keyword arguments to pass to the data formatter method

Returns:

Type Description
dict keys data and layout for Dash
View Source
    def create_figure(self, df_raw, **kwargs_data):
        """Create the figure dictionary.

        Args:
            df_raw: data to pass to formatter method
            kwargs_data: keyword arguments to pass to the data formatter method

        Returns:
            dict: keys `data` and `layout` for Dash

        """
        return {
            'data': self.create_traces(df_raw, **kwargs_data),
            'layout': go.Layout(self.apply_custom_layout(self.create_layout())),
        }

create_layout⚓︎

def create_layout(
    self
)

Extend the standard layout.

Returns:

Type Description
dict layout for Dash figure
View Source
    def create_layout(self):
        """Extend the standard layout.

        Returns:
            dict: layout for Dash figure

        """
        layout = super().create_layout()
        # Set YAxis tick marks for category names (https://plotly.com/python/tick-formatting)
        layout['yaxis']['tickmode'] = 'array'
        layout['yaxis']['tickvals'] = np.subtract(
            np.multiply(
                np.array(range(len(self.categories))),
                self.y_space,
            ),
            self.rh / 2,
        )
        layout['yaxis']['ticktext'] = [*self.categories]
        layout['yaxis']['zeroline'] = False
        # Hide legend
        layout['legend'] = {}
        layout['showlegend'] = False
        # Add shapes and append new annotations
        layout['shapes'] = self._shapes
        layout['annotations'] += self._annotations
        return layout

create_traces⚓︎

def create_traces(
    self,
    df_raw
)

Return traces for plotly chart.

Parameters:

Name Description
df_raw pandas dataframe with columns: (category, label, start, end)

Returns:

Type Description
list Dash chart traces
View Source
    def create_traces(self, df_raw):   # noqa: CCR001
        """Return traces for plotly chart.

        Args:
            df_raw: pandas dataframe with columns: `(category, label, start, end)`

        Returns:
            list: Dash chart traces

        """
        # Get all unique category names and create lookup for y positions
        self.categories = sorted(cat for cat in set(df_raw['category'].tolist()) if cat)
        y_pos_lookup = {cat: self.y_space * idx for idx, cat in enumerate(self.categories)}
        # Create the Time Vis traces
        traces = []
        self._shapes = []
        self._annotations = []
        for vis in df_raw.itertuples():
            if vis.category in y_pos_lookup:
                y_pos = y_pos_lookup[vis.category]
                if vis.end:
                    traces.append(self._create_time_vis_shape(vis, y_pos))
                    if vis.label:
                        traces.append(self._create_annotation(vis, y_pos))
                else:
                    traces.append(self._create_event(vis, y_pos))
            else:
                y_pos = 0
                traces.append(self._create_non_cat_shape(vis, y_pos))
        return traces

initialize_mutables⚓︎

def initialize_mutables(
    self
)

Initialize the mutable data members to prevent modifying one attribute and impacting all instances.

View Source
    def initialize_mutables(self):
        """Initialize the mutable data members to prevent modifying one attribute and impacting all instances."""
        ...

Last update: August 5, 2022
Created: August 5, 2022